Appearance
Vercel AI SDK 记录
这里主要记录 Vercel AI SDK 的两个底层机制:Data Stream Protocol 解决 streaming + tool calling + metadata 的单连接多路复用问题;Provider Adapter 抽象(LanguageModelV2 接口)负责统一不同 LLM provider 的接入方式。上层 API 如 useChat、streamText、streamObject,本质上都是建立在这两层之上的封装。
useChat 到底做了什么
表面上看 useChat 就是个带状态的 fetch wrapper,实际上它内部维护了一个相当完整的状态机。
核心流程大致是:用户提交消息 → hook 把消息 append 到本地 messages 数组(乐观更新)→ 发 POST 到 /api/chat → 后端 streamText() 返回 ReadableStream → 客户端逐 chunk 解析 → 更新最后一条 assistant 消息的 content。
真正需要关注的不是这条标准链路,而是几个边界情况:
- Abort: hook 内部用 AbortController 管理请求。用户发新消息时,上一个流式响应会被 abort 掉。这避免了竞态——旧响应还在 stream 但 UI 已经切换到新对话。
- 错误恢复:
onError回调能拿到 error 对象,但实际生产里更麻烦的是 stream 中断到一半——前端拿到半条 assistant 消息,后端已经 stream 完了,怎么处理?SDK 默认是保留那半条消息,让用户手动重试。这个行为在onResponse和onFinish之间有不少坑。 - 实验性功能:
experimental_prepareRequestBody这个 option 很少有人提,但如果你需要在请求体里注入额外的 context(比如 session id、user metadata),就只能靠它。
streamText 的返回格式
streamText() 返回的 toDataStreamResponse() 用的是 Vercel 自定义的 data stream protocol,不是 SSE。跟你平时用的 text/event-stream 不一样。
每个 chunk 的格式类似:
text
0:"Hello"
2:{"tool_calls":[{"type":"function","function":{"name":"get_weather","arguments":"{"city":"Beijing"}"}}]}
d:{"finishReason":"stop"}0:前缀 → 文本 delta2:前缀 → tool call 的 JSON 序列化结果d:前缀 → 数据(finish reason, usage 等)3:→ tool resulte:→ 错误9:→ 自定义 metadata
useChat 那端有个解析器,根据前缀把 chunk 分派到不同的 handler。遇到 0: 就 append 到当前 assistant 消息,遇到 2: 就更新 tool_call 状态,遇到 d: 里的 finishReason 就标记对话完成。
这个协议比普通 SSE 更适合 SDK 当前的场景:一个 stream 里可以同时传文本、tool call、metadata,不需要额外拆多个 event type。
但在生产里也遇到过解析失败的情况,一般是后端 middleware 意外修改了响应流(比如某个 Next.js middleware 加了 header 导致 chunk 被截断或者被 gzip 合并)。
Provider 抽象层
Vercel AI SDK 更像一个 provider-agnostic 的 adapter 框架,而不只是单纯的 AI library。
LanguageModelV2 接口定义了 provider 需要实现的契约:
ts
interface LanguageModelV2 {
specificationVersion: 'v2';
provider: string;
modelId: string;
doGenerate(options): Promise<GenerateResult>;
doStream(options): Promise<StreamResult>;
}每个 provider(OpenAI、Anthropic、Google、Mistral...)只需实现 doGenerate 和 doStream。上层 streamText / generateText / streamObject 完全不知道底层是什么模型,全部通过 adapter 调用。
这个设计的实际价值在于切换模型不需要改业务代码。从 openai('gpt-4o') 切到 anthropic('claude-sonnet-4-6') 就是换一个函数调用,prompt、tool definition、stream handling 全都一样。
实际落点在 prompt 兼容性上。同一个 system prompt,GPT 和 Claude 的理解方式可能并不一致,直接切模型并不等于可以无成本复用行为。
Tool Calling 的执行模型
Vercel AI SDK 的 tool calling 跟 LangChain 的 agent loop 不是一回事。SDK 这边更"声明式"——你定义 tools,模型决定调用哪个,SDK 执行 tool,把结果发回模型。
maxToolRoundtrips 控制最大 tool→result→model→tool 的轮次数,默认是 1。调成 3 或 5 可以让模型多步推理,但成本翻倍而且容易循环。
一个实际的坑:tool 执行失败时(比如调用外部 API 超时),SDK 的行为取决于你的 tool 实现。如果 tool 直接 throw,SDK 默认会把这个 error 传给 onError,stream 中断。更好的做法是 tool 返回 error string 作为结果,让模型自己决定怎么处理。
生产环境里的几个实际问题
Stream 中断处理: 用户网络抖动、切后台、VPN 切换都可能导致 stream 中断。useChat 的 reload 方法可以重发最后一条消息,但重发意味着重新调用模型——成本。所以一般会在 onFinish 里缓存完成的对话到 localStorage,前端在 onError 里先检查缓存再决定是否 reload。
多 Tab 同步: 用户开多个 tab,每个 tab 一个 useChat 实例往同一个 conversation 发消息,后端的 messages 数组就对不齐了。SDK 自己不解决这个问题,需要业务层用 id 做幂等。
Token 计数和成本控制: SDK 在 onFinish 里会返回 usage 对象(prompt tokens + completion tokens),但这个数据在某些 provider 下是 undefined(比如某些 Ollama 部署)。另外 streamText 模式下 token 计数是 streaming 完成之后才有的——如果想在 stream 中间中断长回复,需要在 onToken 里做字数计数来近似判断。
Edge Runtime 限制: Vercel Edge 上跑 streamText 有 30s 超时和 4MB 响应体限制。长对话超出上下文窗口或者 tool calling 多轮的话很容易碰到。
跟 LangChain 的取舍
LangChain 的重心在 chain/agent 编排,Vercel AI SDK 的重心在 streaming UX 和 provider 抽象。如果你主要做 chatbot 类产品,SDK 更轻、更容易上生产;如果做复杂的 multi-agent workflow,LangChain 的抽象层(虽然臃肿)至少覆盖了更多场景。
Vercel AI SDK 近几个版本也明显在往 agent 方向靠拢。generateText 配合 maxToolRoundtrips,已经可以组成一个基础的 agent loop,只是没有 LangChain 那么多内置的 memory、planning、reflection 策略。
源码级分析
1. 调用链追踪:从 useChat 到 LLM Provider
拆一条完整的请求链路:
text
[Client] useChat.handleSubmit()
└─> appendMessageToUI(msg) // 乐观更新, setMessages(prev => [...prev, userMsg, assistantMsg])
└─> chatRequest(messages, options) // 核心请求函数
└─> fetch('/api/chat', { body: JSON.stringify({ messages }) })
└─> response.body.getReader() // 拿到 ReadableStream reader
└─> readDataStream(reader) // 逐行解析 stream chunk
├─> onChunk('0:', text) // → appendTextToLastAssistantMessage()
├─> onChunk('2:', json) // → updateToolCalls()
├─> onChunk('d:', data) // → processFinishReason()
├─> onChunk('e:', error) // → onError callback
└─> onChunk('9:', meta) // → custom handler
[Server] POST /api/chat
└─> streamText({ model, messages, tools })
└─> model.doStream({ messages, tools, ... }) // 调用 provider adapter
└─> fetch(providerApiUrl, { body: chatCompletionRequest }) // 调用 OpenAI/Anthropic API
└─> response.body → ReadableStream → AsyncIterator<LanguageModelV2StreamChunk>
└─> DataStreamWriter.write(chunk) // 编码为 data stream protocol
└─> writer.pipeTo(response.body) // 写入 HTTP Response Stream这条链路里有两个关键的异步流:
- Server side: LLM API 返回的 SSE stream → provider adapter 归一化为
LanguageModelV2StreamChunk→DataStreamWriter编码为0:/2:/d:前缀协议 → HTTP Response Stream - Client side:
ReadableStreamreader →readDataStream解析器 → 前缀分派 → React setState
每个环节的背压(backpressure)通过 ReadableStream 的 pull-based 模型自然传递。如果客户端读得慢,服务端的 pipeTo 会使 LLM API 的 stream reader 暂停,往上传递到 provider 的 TCP socket。这意味着整个链路不存在内部 buffer 溢出问题——除非有人在中间插入一个 unbounded buffer(比如某些反向代理)。
2. readDataStream 解析器的内部实现
这是 ai 包里最重要的一个函数,我读源码时做了简化重构:
ts
async function readDataStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
callbacks: {
onText?: (text: string) => void;
onToolCall?: (toolCall: ToolCall) => void;
onData?: (data: DataMessage) => void;
onError?: (error: unknown) => void;
}
) {
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// 最后一行可能不完整,保留到下次循环
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const type = line[0]; // '0' | '2' | 'd' | 'e' | '3' | '9'
const colonIndex = line.indexOf(':');
const content = line.slice(colonIndex + 1);
switch (type) {
case '0': callbacks.onText?.(JSON.parse(content)); break;
case '2': callbacks.onToolCall?.(JSON.parse(content)); break;
case 'd': callbacks.onData?.(JSON.parse(content)); break;
case 'e': callbacks.onError?.(JSON.parse(content)); break;
// ...
}
}
}
}关键点:buffer 拼接逻辑。 网络层传过来的 chunk 可能在任意位置分割,一行完整的 chunk(如 0:"Hello world")可能被 TCP 拆成两个 Uint8Array。解析器用 \n 做分隔符,不完整的最后一行保留在 buffer 里等下一个 chunk 拼接。这是所有流式协议解析器的标准做法。
一个容易出问题的地方:如果 LLM 返回的文本里包含换行符 \n,这个解析器会出错。解决办法是 JSON.stringify 之后的 content 不会有未转义的换行符——JSON.parse(content) 会自动还原。这是为什么 data stream protocol 选择 JSON 编码而不是 raw text。
3. useChat 的状态机
useChat 内部的状态流转可以抽象为 5 个状态:
text
IDLE ──handleSubmit()──> SUBMITTED ──response received──> STREAMING
^ │
│ │ onFinish / error
└──────────────────────────────────────────────────────────┘
│
READY (可继续输入)但实际上还有一个隐藏状态:ABORTING。 当用户在新的 stream 进行中发送消息时:
text
STREAMING ──new handleSubmit()──> ABORTING ──abort() resolve──> SUBMITTED'hook 内部维护的 key 变量:
abortControllerRef:当前请求的 AbortControllermessages:所有消息的数组(包含 user + assistant + tool 消息)isLoading:是否有进行中的请求error:最近一次错误(不清空,手动 clear)
这里有个细节:messages 是 useState 管理的,但 chatRequest 函数内部用的是闭包捕获的 messages(通过 messagesRef 或者直接传参)。如果hook 的 render 频率高,闭包里的 messages 可能是旧的。Vercel 的做法是 useChat 用一个 useRef 来保存最新的 messages,chatRequest 读取 ref.current。
4. Provider Adapter 的接口契约
每个 provider adapter 实现的核心接口:
ts
interface LanguageModelV2 {
specificationVersion: 'v2';
provider: string;
modelId: string;
doGenerate(options: {
mode: { type: 'regular' | 'object-json' | 'object-tool'; schema?: JSONSchema };
prompt: LanguageModelV2Prompt;
maxTokens?: number;
temperature?: number;
tools?: Tool[];
toolChoice?: 'auto' | 'none' | 'required' | { type: 'tool'; toolName: string };
headers?: Record<string, string>;
}): PromiseLike<{
text?: string;
toolCalls?: ToolCall[];
finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown';
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
rawResponse?: { headers?: Record<string, string> };
}>;
doStream(options: SameAsAbove): PromiseLike<{
stream: ReadableStream<LanguageModelV2StreamPart>;
rawResponse?: { headers?: Record<string, string> };
}>;
}streamText 调用 doStream,拿到 ReadableStream<LanguageModelV2StreamPart>,然后用自己的 DataStreamWriter 把每个内部 chunk type 映射到 data stream protocol 的前缀。
Provider 适配的关键在这里:不同 LLM API 返回的 SSE 格式完全不同。 OpenAI 返回 data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n,Anthropic 返回的是 data: {"type":"content_block_delta","delta":{"text":"Hello"}}\n\n。Provider adapter 的工作就是把这些异构格式归一化为 LanguageModelV2StreamPart:
ts
type LanguageModelV2StreamPart =
| { type: 'text-delta'; textDelta: string }
| { type: 'tool-call'; toolCallId: string; toolName: string; args: string }
| { type: 'tool-result'; toolCallId: string; toolName: string; result: unknown }
| { type: 'finish'; finishReason: string; usage: ... }
| { type: 'error'; error: unknown };上层代码完全不用关心是 GPT 还是 Claude。
5. 调度模型:为什么没有 React Scheduler / Vue Scheduler
React 自己有一套调度(Scheduler + Lane + concurrent mode),Vue 有异步队列(nextTick + scheduler),但 Vercel AI SDK 的 streaming 更新不经过这些调度层。
原因:streaming 的每个 chunk 是通过 reader.read() 的 microtask 链传播的。每读到一行 chunk → 立即调 setState → React 在当前 task 结束后批量渲染。这是一个自然的异步链:
text
microtask: reader.read() resolve
→ microtask: setState
→ microtask: React re-render (batched by React internally)
→ microtask: next reader.read()整个链路不依赖任何显式调度。setState 的频率取决于 LLM 吐 token 的速度(通常 30-80 tokens/s),远低于 React 的 60fps 渲染上限,所以不存在"更新太频繁导致掉帧"的问题。
但有一个调度隐患:如果 LLM 吐得特别快(比如本地模型 >200 tokens/s),setState 会在同一个 event loop tick 内多次被调用。React 18+ 的 automatic batching 会把同一个 event handler 内的多次 setState 合并,但 streaming 的 reader.read() 每个 chunk 属于不同的 microtask,automatic batching 不一定生效。Vercel 的做法是内部维护一个 throttle(默认 50ms),超过这个间隔才触发一次 setState,把多个 chunk 合并到一次渲染。
6. 内存模型:消息列表的引用链
text
useChat.messages: Message[]
├─ [0] Message { id, role: 'user', content: '...' }
├─ [1] Message { id, role: 'assistant', content: '...' }
├─ [2] Message { id, role: 'user', content: '...' }
└─ [3] Message { id, role: 'assistant', content: '...', toolInvocations: [...] }每条 Message 是一个 plain object。Streaming 期间,最后一条 assistant 消息的 content 字段每次 chunk 都被替换(新字符串)。React 的 setState 触发 UI 重新渲染,但旧 content 字符串被 GC 回收。
潜在的内存问题在长对话:如果用户持续对话 200+ 轮,messages 数组不断 append,每条消息的 content 可能上千字符。200 轮 × 平均 500 字符/条 = 100KB。看起来不大,但 React 的 useState 会在每次 setState 时创建新数组(不可变更新),streaming 阶段每秒 30-80 次 setState → 每秒 3-8MB 的临时数组分配。这些临时对象会在当前渲染周期结束后被 GC,但在低端移动设备上,频繁 GC 会导致 UI 卡顿。
这也是为什么 Vercel AI SDK 建议在生产中对 messages 做截断——不是 token 超限问题,而是内存和 GC 压力。
7. Tool Calling 的轮次执行流程
带 tool calling 的 streamText 内部执行流程比普通 chat 复杂得多:
text
streamText({ model, messages, tools, maxToolRoundtrips: 3 })
│
├─ Round 1
│ ├─ model.doStream({ messages, tools })
│ ├─ stream chunks → client (text + tool_calls)
│ └─ model 返回 tool_calls: [{ get_weather, { city: "Beijing" } }]
│
├─ Round 2
│ ├─ 执行 tool: get_weather({ city: "Beijing" }) → "25°C, 晴"
│ ├─ 构造新 messages: [...old, assistant(tool_calls), tool(result)]
│ └─ model.doStream({ messages: newMessages, tools })
│
└─ Round 3
└─ model 返回 final text (no tool_calls) → stream 结束核心点:每轮 tool calling 都是一次全新的 model.doStream() 调用。 这意味着每轮都会重新发送整个 messages 数组(包含前面所有的 user + assistant + tool 消息)给 LLM API。如果 maxToolRoundtrips: 5,messages 在 5 轮调用中不断膨胀,token 消耗几乎是线性增长。
这也是 maxToolRoundtrips 默认值为 1 的原因——超过 1 轮的成本很高,且容易陷入 tool→model→tool→model 的循环。
8. 复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| useChat.handleSubmit | O(1) | 追加一条消息 + 发起 fetch |
| readDataStream 解析 | O(n) n=chunk数量 | 每个 chunk 一次正则/前缀匹配 |
| setState 更新 | O(m) m=messages数量 | React 的不可变更新,全量替换数组引用 |
| streamText 单轮 | O(t+p) t=tokens p=prompt | LLM 推理的时间复杂度取决于 token 数 |
| Tool Calling N 轮 | O(N×(t+p)) | 每轮重新发送完整 messages 数组 |
| Provider 切换 | O(1) | adapter 模式,无运行时开销 |
浏览器运行时分析
1. fetch + ReadableStream:浏览器如何消费流式响应
useChat 发起的是一个标准的 fetch() 请求,关键参数是 response.body:
ts
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages }),
});
const reader = response.body.getReader();response.body 是一个 ReadableStream<Uint8Array>。在浏览器里,fetch 收到第一个字节后 response 就 resolve 了——不需要等完整响应体。这是 Streaming 能在浏览器工作的基础。
getReader() 返回一个 ReadableStreamDefaultReader,调用 reader.read() 返回 { done: boolean, value: Uint8Array }。这个方法返回 Promise,意味着每次 read() 都进入 microtask 队列。
浏览器内部,fetch 的数据流经以下路径:
text
Network Process (收到 TCP 数据)
└─> HTTP Parser (解析 chunked transfer-encoding 或 raw stream)
└─> DataPipe (跨进程传递,IPC)
└─> Renderer Process (JS 引擎所在的进程)
└─> Fetch API (构造 ReadableStream)
└─> reader.read() → microtask → JS callback从网络包到 JS 回调至少要经过一次进程间通信(IPC)。如果浏览器使用了 site isolation,这个 IPC 延迟在 0.5-2ms 左右。LLM streaming 的 token 速率是 30-80/s,相当于每个 chunk 间隔 12-33ms,IPC 延迟完全可忽略。
但有一个值得注意的点:reader.read() 不会因为网络就绪就立即 resolve。 read() 在以下两种情况下 resolve:
- 内部 buffer 中有可用数据(至少 1 byte)
- stream 被关闭(done: true)
Chrome 的 fetch ReadableStream 内部 buffer 约 64KB。LLM API 返回的每个 token 只有几个字节,buffer 大部分时间几乎是空的,所以 read() 的行为接近于"每收到几个 token 就 resolve 一次"。具体 chunk 大小取决于 Nagle 算法、TCP 合并、以及 LLM API 端的 flush 策略。
2. Event Loop:Stream Chunk 与 React setState 的交互
每个 streaming chunk 带来的事件链:
text
Task: Network data arrives (IPC)
└─> microtask: reader.read() resolves
└─> microtask: chunk 解析 → setState()
└─> microtask: React schedules re-render (SyncLane)
└─> microtask: React commit → DOM 更新
└─> 浏览器:requestAnimationFrame 前,可能触发 layout/paint这里的关键问题:每个 chunk 都是一次完整的 React 渲染周期。 如果 LLM 吐 50 tokens/s,每秒就有 50 次 setState → 50 次 React render → 50 次 DOM 更新。React 18 的 automatic batching 只针对同一个 microtask 内的 setState——不同的 read() microtask 之间不会合并。
Vercel AI SDK 内部用一个简单的 throttle(~50ms 间隔)来处理这个:超过 50ms 才 dispatch 一次 setState,把期间积压的 chunk 合并。
但即使合并到每 50ms 一次,也是 20 次/秒的渲染频率。对于 text-only 更新(只改文本节点),这个开销通常是可接受的(React 的 diff 在文本节点上是 O(1) 的)。但如果聊天 UI 有复杂的 markdown 渲染(code highlight、表格、mermaid 图表),每次渲染都要重新 parse → AST → highlight → DOM,成本可能超过 50ms,导致掉帧。
requestAnimationFrame 角度: 浏览器每 16.6ms 执行一次 rAF → style → layout → paint → composite。如果 React 在 rAF 之间做了 3 次 setState,浏览器只会在下一次 rAF 时渲染一次——中间的 DOM 变化不会产生额外的 layout/paint。这是浏览器的自然合并机制。但如果 setState → React render 本身耗时 >16.6ms,就会导致 rAF 延迟或跳过。
3. TextDecoder 的 Stream 模式
readDataStream 解析器里用到了 TextDecoder:
ts
const decoder = new TextDecoder();
buffer += decoder.decode(value, { stream: true });{ stream: true } 参数告诉 TextDecoder:这个 Uint8Array 可能是不完整的 UTF-8 序列,不要把末尾的无效字节替换为 � (U+FFFD),而是保留在内部 buffer 里等下次 decode。
UTF-8 字符可能由 1-4 个字节组成。如果 TCP chunk 恰好在 multi-byte 字符中间切分,不带 { stream: true } 的 decode() 会产出乱码。LLM streaming 输出中文时(每个中文字符 3 字节 UTF-8),在 60KB buffer 边界出现截断是大概率事件。
TextDecoder 的 stream 模式在内部维护了一个小的 decode buffer(几个字节),专门处理这种截断。
4. AbortController 在浏览器层的语义
useChat 用 AbortController 来取消请求:
ts
const abortControllerRef = useRef(new AbortController());
// 新消息到来时
abortControllerRef.current.abort();
abortControllerRef.current = new AbortController();
fetch(url, { signal: abortControllerRef.current.signal });abort() 在浏览器层面的行为:
- fetch promise 立即 reject,抛出一个
AbortErrorDOMException - 底层 TCP 连接不一定关闭——如果同 origin 有其他请求复用同一个 HTTP/2 连接,连接保持;如果是独立的连接,浏览器会发 RST 包关闭
- ReadableStream reader 的 read() 会 reject——但已读出的 chunk 不会回滚
一个值得注意的细节:abort() 后立即创建新的 AbortController 并发起新 fetch。两个请求在短时间内共存——旧请求的 TCP 连接可能在关闭中(RST 未到达服务端),新请求已经发出。对于 HTTP/2,这两个请求可能走同一个连接,但如果服务端处理较慢,旧请求的数据可能还在往客户端发。浏览器会忽略已 abort 的 response。
5. 内存和 GC 压力
Streaming 期间,每个 chunk 的生命周期:
text
Uint8Array (网络层, ~几个KB)
→ 拼接 buffer (string, ~几个KB)
→ JSON.parse (新 string, 文本 delta)
→ setState → 新 Message 对象 (copy 整个 messages 数组)
→ React render → React elements → Fiber tree → DOM最耗内存的是 setState 这一步。 React 的 useState 每次调用都是用新数组替换旧数组(不可变更新)。如果 messages 数组有 50 条消息,每条消息平均 200 字符,加上 React 的 Fiber 树节点、DOM 节点等,单次更新的临时对象可能是 messages 数据的 3-5 倍。
20 次/秒的更新频率 × 每条消息的数据量,在长对话中 GC 压力显著。
Chrome 的 Scavenger(新生代 GC)在 streaming 期间会被频繁触发。好消息是大部分临时对象生命周期很短,会被 Scavenger 高效回收(只复制存活对象)。但如果 streaming 期间创建了大量长期存活的对象(比如闭包、定时器),它们会被提升到老生代,增加 Major GC 的频率。
一个实际的优化: 对于消息列表组件,用 React.memo + 只传 message.id 做 key,让 React 跳过未变化消息的重新渲染。Vercel AI SDK 的 useChat 不提供这个,需要在业务层手动处理。
6. 渲染管线的成本评估
| 操作 | Layout | Paint | Composite | 频率 |
|---|---|---|---|---|
| 文本内容追加(append text) | ✅ 可能触发 | ✅ | ❌ | 每 50ms |
| 消息列表增长(新增消息) | ✅ | ✅ | ❌ | 每轮对话 |
| loading spinner 动画 | ❌ | ✅ | ✅ | 每帧 |
| Markdown 代码高亮 | ❌ | ✅ | ❌ | 每 chunk |
文本内容追加会触发 layout:新增的文本节点可能导致后续元素位置偏移(auto-scroll 到底部)。但现代 Chrome 的 incremental layout 机制只重新计算受影响的部分,不是全量 layout。
loading spinner 如果是 CSS animation(transform: rotate()),整个动画在 compositor 线程完成,不触发主线程的 layout/paint。useChat 的 isLoading 状态切换时,loading spinner 的 mount/unmount 会触发一次 layout,但「旋转」本身不影响主线程。
可能出问题的是 auto-scroll。 很多 AI 聊天 UI 会在每次内容更新时调用 scrollIntoView() 或手动设置 scrollTop。这两个操作都会触发 forced synchronous layout(强制同步布局)——浏览器必须立即计算布局才能返回正确的 scroll 值。Streaming 期间每 50ms 强制 layout 一次,对低端设备的压力不小。
性能模型分析
1. 延迟预算分解
一次完整的 AI 对话 round trip(用户发送消息 → 看到第一个 token)延迟可以拆解为:
text
总 TTFB (Time To First Byte/Token)
= DNS lookup(首次:~50ms,缓存:0ms)
+ TCP/TLS handshake(HTTP/2 复用连接时:0ms)
+ Next.js API Route cold start(Edge: <100ms, Serverless: 200-500ms, Node: 即时)
+ LLM API network RTT(取决于机房位置,通常 50-200ms)
+ LLM prompt processing time(输入 tokens × 处理速度,GPT-4o ~1000t/s 输入处理)
+ LLM TTFT(Time To First Token,输出前置延迟,~100-500ms)
= 总计:最优 ~200ms,典型 ~500-1500ms流式传输能 mask 的是「生成阶段」的延迟——用户不需要等完整回复,第一个 token 到达就开始展示。但 prompt processing + TTFT 这部分是用户必然感知到的「等待时间」。
useChat 在等待期间的行为:handleSubmit 后,UI 立即显示 user 消息 + 空的 assistant 消息(带 loading indicator)。TTFB 超过 1s 时,用户会注意到 "..." 在闪烁。这是纯心理体验问题,但从性能指标角度看:
- INP(Interaction to Next Paint): 用户点击「发送」到 UI 显示 user 消息 → <50ms(仅 DOM 操作,无网络),理想
- LCP(Largest Contentful Paint): 对话页面首屏已有内容,新消息不参与 LCP 评分
- CLS(Cumulative Layout Shift): 新消息 append 可能引起布局偏移,如果消息列表是
flex-direction: column且overflow-y: auto且已经滚到底部,新内容自然增长不产生 CLS
2. Streaming 吞吐量的瓶颈分析
LLM 输出速率典型为 30-80 tokens/s(GPT-4o ~60t/s,Claude Sonnet 4 ~80t/s)。对前端来说:
- 30 tokens/s = 每 33ms 一个 token
- 80 tokens/s = 每 12.5ms 一个 token
Vercel AI SDK 的 50ms throttle 在 30t/s 下合并 ~1-2 个 token,在 80t/s 下合并 ~4 个 token。
瓶颈不在前端 JS 处理(字符串拼接 + JSON.parse 是微秒级),而在 React 渲染管线:
| LLM 速度 | chunk 间隔 | throttle 后频率 | 单次 React Render | 占 16.6ms 帧预算 |
|---|---|---|---|---|
| 30 t/s | 33ms | 50ms (20次/s) | ~2-5ms (文本更新) | 12-30% |
| 80 t/s | 12.5ms | 50ms (20次/s) | ~2-5ms (文本更新) | 12-30% |
| 200+ t/s | <5ms | 50ms (20次/s) | ~2-5ms (文本更新) | 12-30% |
纯文本更新时,React render 成本低——只改最后一条消息的一个文本节点。但如果消息里有:
- Markdown / code highlight 组件: 每次 render 可能触发 rehype/highlight.js 重新处理,成本 10-50ms
- React Markdown 组件: AST parse + React element tree 构造,每条消息 5-20ms
- 复杂 tool invocation UI: 天气 card、地图组件、图表等,每个 10-100ms
出现任何一项超过 16.6ms 就会掉帧。
3. 内存分配率量化
以 60t/s 的 LLM 输出速率、50ms throttle 为例:
text
每次 setState:
└─ 新 messages 数组: ~50 条消息 × 平均 500 字符 = 25KB (数组引用)
└─ React Fiber 节点重建: ~50 节点 × 200 bytes = 10KB
└─ DOM 更新: 仅改动的文本节点,~几百 bytes
每秒 20 次 × 35KB = 700KB/s 临时对象分配
每分钟 = 42MB 临时对象Chrome V8 的 Scavenger(新生代 GC)默认 semi-space 大小为 8MB。每分钟 42MB 分配意味着 Scavenger 每分钟触发 5+ 次,每次暂停 0.5-2ms。在现代设备上这完全可接受,但在低端 Android(2GB RAM)上可能成为问题。
可优化的点: 如果业务代码在 streaming 期间保存了每条消息的快照(比如 undo/redo 功能),旧版本的消息数组不会被 GC,内存增长会从 700KB/s 飙到数倍。
4. Edge Runtime 的性能权衡
streamText 部署在 Vercel Edge 上的性能特征:
优点:
- 冷启动 <100ms(vs Serverless 的 200-500ms)
- 全球分发,LLM API 就近访问(减少 50-150ms RTT)
- 零连接池开销(每个请求独立)
限制:
- 30s 硬超时(tool calling 多轮容易触碰)
- 4MB 响应体限制(长对话可能接近)
- 无 Node.js 原生模块(某些 provider adapter 不兼容)
- 无文件系统访问(不能写日志、不能读本地 config)
- 内存限制 ~128MB(Edge Worker 的分配上限)
对于 AI 聊天场景,Edge Runtime 的 30s 超时是最关键的约束。一次 LLM API 调用可能 5-10s,tool calling 3 轮就是 15-30s,已经接近边界。
性能优化策略:
- 对
maxToolRoundtrips设置严格的 token 预算 —— 超过预算就放弃后续轮次,直接返回已有结果 - 将长时间 tool calling 拆到 background function —— Edge 只负责调用 LLM,慢的 tool 执行走 Serverless
- 对重复请求做缓存 ——
streamText的结果可以按 messages hash 缓存(对相同 system prompt + 消息的请求返回缓存结果),但实际实现较复杂
5. 渲染优化 Checklist
| 优化措施 | 影响 | 实现难度 |
|---|---|---|
| React.memo + id key | 跳过未变化消息的重渲染 | 低 |
| Markdown 渲染缓存 | 避免每 chunk 重新 parse AST | 中 |
使用 content-visibility: auto | 跳过长对话列表视口外消息的 layout | 低 |
| 拆分消息组件(text vs tool vs image) | 减少单组件复杂度 | 中 |
| scroll 方向检测判断是否 auto-scroll | 避免不需要的 forced layout | 低 |
| Web Worker 做 Markdown parse | 主线程不阻塞 | 高 |
| Virtualized message list | 超过 200 条消息时的必需优化 | 高 |
架构设计分析
1. 系统边界
Vercel AI SDK 的架构可以划分为四个独立的子系统:
text
┌──────────────────────────────────────────────────────────────┐
│ Client Layer (ai/react) │
│ useChat / useCompletion / useObject / useAssistant │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 状态管理 │ │ Stream Parser│ │ AbortController │ │
│ │ messages │ │readDataStream│ │ request lifecycle│ │
│ └──────────┘ └──────────────┘ └──────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ Core Layer (ai) │
│ streamText / generateText / streamObject / generateObject │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │DataStreamWriter│ │ Tool Executor│ │ Prompt Builder │ │
│ │ (协议编码器) │ │(tool calling)│ │ (system+msgs) │ │
│ └──────────────┘ └──────────────┘ └────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ Provider Adapter Layer │
│ ┌─────────┐ ┌──────────┐ ┌────────┐ ┌───────────┐ │
│ │ OpenAI │ │Anthropic │ │ Google │ │ Custom │ │
│ │ Adapter │ │ Adapter │ │Adapter │ │ Adapter │ │
│ └────┬────┘ └────┬─────┘ └───┬────┘ └─────┬─────┘ │
│ │ │ │ │ │
│ └────────────┴────────────┴──────────────┘ │
│ LanguageModelV2 Interface │
├──────────────────────────────────────────────────────────────┤
│ External LLM APIs │
│ OpenAI API │ Anthropic API │ Google AI API │ ... │
└──────────────────────────────────────────────────────────────┘系统边界:
- 内部: Client Layer(React hooks)、Core Layer(streamText 等)、Provider Adapter Layer
- 外部: LLM API providers、用户的应用代码、React/Vue/Svelte 框架
- 依赖方向: Client → Core → Adapter → External API(单向,下层不对上层有依赖)
2. 架构质量评估
模块化
SDK 采用 monorepo 多包架构,每个包职责单一:
ai— 核心运行时,provider-agnostic,包含 data stream protocol 实现、tool calling 引擎@ai-sdk/openai等 — provider adapter,仅依赖ai包定义的接口ai/react— React 绑定,包含所有 hooksai/vue— Vue 绑定
这种分离的核心收益:provider adapter 的变更不影响 hook 层,hook 层的 React 版本升级不影响 provider adapter。 耦合仅通过 LanguageModelV2 接口传递。
耦合控制
最关键的架构决策是引入 LanguageModelV2 接口作为所有 provider 的契约。这类似 Java 的 JDBC——只要 driver 实现了标准接口,上层代码完全不感知底层数据库。
但有一个泄漏抽象的问题: doGenerate 和 doStream 的 options 参数包含 headers 字段。这意味着上层可以通过 adapter 往 LLM API 注入自定义 header。如果业务代码依赖某个 provider 特有的 header(比如 OpenAI 的 OpenAI-Organization),那切换 provider 时就会 break。这是实用主义的设计折中——严格封装会限制高级用法。
关键设计决策的 Tradeoff
| 决策 | 收益 | 成本 | 风险 |
|---|---|---|---|
| 自定义 data stream protocol(而非 SSE) | 单 stream 承载文本+tool call+metadata,协议统一 | 与非 Vercel 客户端不兼容;自研解析器维护成本 | 如果社区转向其他协议,迁移成本高 |
| Provider adapter 模式 | 切换模型零业务代码改动 | 每个 provider 需要独立维护 adapter;新 API feature 需要各个 adapter 支持 | Adapter 维护跟不上 provider API 变更 |
| 乐观更新(useChat 先 append user 消息再发请求) | 即时 UI 响应,感知延迟为 0 | 请求失败时需要回滚/标记错误 | 消息顺序可能在网络异常时混乱 |
| React/Vue/Svelte 各一套 hook 实现 | 框架原生 API 风格,学习成本低 | 三套 hook 实现需要同步维护;bug 修复需三处 | 框架间行为不一致 |
| maxToolRoundtrips default=1 | 避免无限循环、控制成本 | 复杂多步推理需要手动提升 limit | Tool calling 不够智能时体验差 |
3. 可扩展性瓶颈
当前瓶颈排序
Token 消耗的线性增长。 Tool calling 每轮重新发送完整 messages 数组,messages 越长、roundtrip 越多,token 消耗几乎 O(N×R)。这是架构层面的问题,API 层面无法解决(LLM 无状态,必须传完整上下文)。
单 connection 的 Streaming 吞吐上限。
useChat每个实例维护一个独立的 fetch → ReadableStream。对于并发场景(比如 agent 同时调用 3 个 tool),当前架构只支持串行 tool calling(round 1 → round 2 → round 3),不支持并行。如果要支持并行 tool calling,需要改造 tool executor 为并发模型——但 LLM 返回的 tool calls 本身是串行的,所以这是 LLM 协议的限制,不是 SDK 的问题。Client-side message 数组无限增长。 用户在单个 conversation 里发 1000 条消息,
messages数组 1000 条,React state update 复制 1000 条数组的引用 → 约 8KB 纯引用 + 内容大小。浏览器 tab 的 heap 可能从 50MB 涨到 200MB+。Provider adapter 维护的 N 倍负担。 OpenAI 发布了新 API feature(比如 structured output),每个 provider adapter 需要分别实现。如果 10 个 adapter,就是 10 倍工作量。Vercel 目前的策略是优先支持主流 provider(OpenAI、Anthropic、Google),社区贡献其他 adapter。
10x 规模的瓶颈
对话量从 100 条 → 1000 条的消息时,唯一可行的方案是虚拟化消息列表 + 剪枝早期消息。虚拟化解决 DOM 节点数问题,剪枝解决 LLM context window 和 token 成本问题。但这两件事 Vercel AI SDK 都不做——它提供的是 primitives,不是产品级的 chat 方案。
4. 故障模式分析
| 故障场景 | 影响 | 默认行为 | 恢复能力 |
|---|---|---|---|
| LLM API 返回 429(限流) | fetch reject | onError 回调 + error 状态 | 需手动实现 retry + backoff |
| Stream 中断(网络断开) | reader.read() 的 Promise pending 直到超时 | 浏览器约 30s 超时后 reject | reload() 重发,但消息可能重复 |
| Tool 执行超时 | streamText 卡在 tool execution 阶段 | 取决于 tool 实现(throw vs return error) | 需在 tool 内实现 timeout |
| LLM 返回的 JSON 不合法(tool args) | tool 无法执行 | model 有时会自纠正,有时直接 fail | 可尝试 prompt 约束 |
| Provider adapter 内部的异常 | stream 中断,前端只看到「stream error」 | onError 回调 | 中间件式 error handler |
| Edge Runtime 30s 超时 | 连接被 Vercel 强制断开,前端收到 truncated response | onError 或 close 事件 | 需要拆分为健康度检查 + 重试 |
最容易被忽视的故障: LLM API 正常返回了 200,但 body 是空 stream(0 token)。这发生在某些 provider 对不支持的消息格式返回「空回复」而非 error。前端表现为:用户发了消息,收到空的 assistant 回复,消息 "发送成功" 但什么都没显示。useChat 的 onFinish 会正常触发,onError 不会触发。
5. 演进路径
V1(当前):Primitive API 提供者
SDK 提供的是底层能力(streamText, useChat, tool calling),业务方需要自己处理:
- 消息持久化(数据库存取)
- 错误重试逻辑
- 多用户并发
- 成本和 rate limiting
- 自定义 UI 组件
V2(进行中):Agent 能力
Vercel AI SDK 通过 maxToolRoundtrips + generateText 往 agent 方向演进。但与 LangChain/LangGraph 不同,Vercel 的策略是「轻量 agent」——在已有 streaming 架构上叠加 loop 能力,而非重新设计一个 agent 框架。
V3(推测):多 Agent 协作 + 长连接
可能的方向:
- WebSocket transport(替代 HTTP streaming,解决双向通信,让 server 可以主动 push 消息)
- Agent 间通信协议(agent-to-agent handoff)
- 内置消息窗口管理(自动 trimming、summarization)
- 离线 queue(用户发消息 → 队列 → 异步 stream 结果 → push notification)
6. 架构中缺失的关键能力
无内置消息持久化层。
useChat的 messages 存 React state 里,刷新页面就丢了。业务方需要自己存到 DB。这是刻意的设计选择——SDK 不绑定任何数据库,但意味着每个团队都在重复造轮子。无分布式会话管理。 多实例部署时,streaming 的 HTTP connection 绑定在单个实例上。如果该实例挂掉,stream 断开且无法迁移到其他实例。这是 HTTP + streaming 架构的天然限制,WebSocket + sticky session 或 Redis pub/sub 是常见解决方案,但 SDK 层面不提供。
无内置 observability。 SDK 不提供 tracing、metrics、logging 基础设施。Vercel 自家的 AI Gateway 补充了部分(token 用量、延迟),但在自建部署中需要自己加 OpenTelemetry。
以上三点对于玩具项目不重要,但对于生产级 AI 产品来说是必须自己补的。这可能是 Vercel 的商业策略——SDK 免费,observability/gateway 走付费。但架构评估不能忽略这些 gap。
工程判断
这个 SDK 到底解决了什么问题?
真正的价值在于把 LLM 调用的异步复杂性封装成了对前端开发者友好的 primitive。
在 Vercel AI SDK 出现之前,做一个 streaming chatbot 需要:
- 手动管理 ReadableStream 解析
- 自己写 SSE/stream 格式处理
- 处理 abort、重连、竞态
- 维护消息状态机(loading/streaming/done/error)
- 对接不同 LLM API 的不同格式
每个团队都在重复造这些轮子,而且大多数团队做得不好——竞态 bug、内存泄漏、流式中断的各种 corner case。
SDK 把这一切封装进 useChat 一个 hook 里。代价是你必须接受它的 state management 模型和协议。
为什么自定义 Data Stream Protocol 比 SSE 好?
这不是一个显而易见的答案。SSE 的优势在于:
- 浏览器原生支持(
EventSourceAPI) - 简单、纯文本
- 自动重连
但 SSE 有两个致命限制:
- 单 event type 的语义单一。 你要在一个 stream 里同时传输文本 delta、tool call、tool result、metadata——要么用 5 个不同的 event type(SSE 支持),但每个 event type 的 payload 都是字符串,解析逻辑分散。
EventSourceAPI 不支持 POST 请求和自定义 header。 这是 SSE 的浏览器实现的限制,不是协议本身的问题。但实际后果是你只能用 GET,这对发送 messages 数组来说很不现实(URL 长度限制)。
Vercel 的方案:用 fetch + ReadableStream + 自定义 line-based protocol。每个 type prefix 对应一种数据类型,统一在一个 stream 里传输。客户端用 readDataStream 统一解析。代价是放弃了 EventSource 的自动重连——不过对于 AI 聊天来说,自动重连的行为本身就难以定义(重连后要不要从断点继续?要不要重新调用 LLM?)。
跟 LangChain 比,差在哪、好在哪?
LangChain 的问题是抽象太多。 你想调一个 LLM,要经过 ChatOpenAI → LLMChain → ConversationChain → Agent Executor → Tool 五层抽象。每一层都有配置项,组合起来的行为难以预测。用 LangChain 做 demo 很快(三行代码一个 agent),上生产就头疼——错误被中间层吞、prompt 拼出来跟预期不一样、memory 管理不符合实际场景。
Vercel AI SDK 的策略是「少抽象、多 primitive」。 streamText 给你一个 stream,你自己决定怎么处理。Tool calling 是声明式的(定义 tool schema),不是编排式的(定义 agent loop)。这使得行为更可预测,代价是复杂场景(特别是需要 planning 的 multi-step agent)需要业务方自己写 loop。
选型落点:
- chatbot 产品:Vercel AI SDK
- agent workflow(多步推理、planning、reflection):LangChain/LangGraph 或自建 loop
- RAG:两者都可以,但 Vercel AI SDK + 自己的 vector DB wrapper 会更轻
生产环境里最容易被忽视的三个风险
1. Stream 中断导致的状态不一致。 用户看 assistant 消息显示了一半,实际上后端 LLM 已经吐完了但网络断了。SDK 默认保留半条消息——如果用户手动 reload,后端会重新调用 LLM,前后响应可能不一致。需要通过消息 ID 做幂等,reload 时先查数据库,如果该消息已完成就恢复,否则重新 generate。
2. Provider Adapter 层的隐藏行为差异。 同一个 prompt + temperature=0 在 OpenAI 和 Anthropic 上产出不同结果。更隐蔽的是 tool calling 的 arg 格式——OpenAI 返回 stringified JSON,Anthropic 返回已解析的 object。虽然 SDK adapter 做了归一化,但 prompt 层面的 behavior difference 是 adapter 层面解决不了的。
3. Edge Runtime 的超时边界。 30s 是硬限制,但 LLM API 调用 + tool execution 的总时间并不稳定。上线前通常需要在 Edge Function 里补 instrumentation,监控 P50/P95/P99 延迟,并识别接近 30s 边界的请求。复杂 tool calling 场景则更适合切到 Serverless(60s 超时)或 Long-running Function。**
最终的工程结论
Vercel AI SDK 是目前前端 AI 开发里最务实的选择。它不完美——缺少持久化、observability、分布式会话管理,但这些是刻意的设计选择:把 SDK 的范围限定在「LLM 调用的前端友好封装」,把更高层的产品需求留给业务方和 Vercel 的付费服务。
它的核心价值从技术上讲只有两点:
- Data Stream Protocol 设计——干净地解决了 streaming + tool calling + metadata 的传输问题
- Provider Adapter 抽象——让切换模型像换一个函数调用
理解了这两点,再看 useChat、streamText 这些 API 就都能还原到源码层面的理解了。
生产环境故障模式
以下案例来自一个日均 50 万次对话的生产环境。
案例 1:Stream 中断的静默失败
现象: assistant 消息只显示了一半,无错误提示,无重试按钮。刷新页面后该消息从对话历史中消失。
排查过程:
text
Chrome DevTools → Network tab → 找到 /api/chat 请求
→ Status: 200(请求"成功"了)
→ Response tab: 只看到部分 stream chunk
→ Timing tab: 在 12.3s 的位置连接中断
对应到后端日志:
→ Vercel Edge Logs: streamText 正常执行完成(耗时 4.2s)
→ 但 LLM API 返回了完整的 response
→ Edge Function 在返回 response 后立即结束
→ 客户端在 12.3s 处断连(用户切换了 WiFi)
→ 前半部分消息在客户端 state 中,但从未持久化根因: Vercel Edge Runtime 的 HTTP response 是 pipe 到客户端 TCP socket 的。Edge Function 执行完 return result.toDataStreamResponse() 后进程就结束了,但 TCP socket 的发送 buffer 可能还没清空。如果客户端这时断连,buffer 中的数据就丢了,且 Edge 侧没有持久化。
解决方案:
- 在
onFinish回调中将完整消息持久化到后端 DB - 前端
reload()时先查 DB,找到已完成的消息就直接恢复,不重新调 LLM - 对超长消息(>30s)开启定时同步,streaming 期间每 5 秒 snapshot 一次
ts
// 此方案在生产环境验证一年,未出现重大故障
const { messages, reload } = useChat({
onFinish: async (message) => {
await fetch('/api/messages/persist', {
method: 'POST',
body: JSON.stringify({ conversationId, message }),
});
},
onError: async (error) => {
// 先查 DB,看这条消息是否已持久化
const existing = await fetch(`/api/messages/${conversationId}/last`);
if (existing.ok) {
// 从 DB 恢复,不重新调 LLM
const { message } = await existing.json();
setMessages(prev => [...prev.slice(0, -1), message]);
}
},
});案例 2:Tool Calling 循环导致的成本爆炸
现象: token 使用量日环比涨 3 倍,部分请求响应时间超过 15 秒。
根因分析: search_documents tool 在某些 query 下返回了空结果([])。模型收到空结果后,认为搜索方式有问题,换了一种 query 再次调用 tool——结果还是空。由于 maxToolRoundtrips: 5,模型在 5 轮搜索中都得不到结果,每轮重新发送前一轮的 tool call + tool result,messages 数组膨胀,token 消耗线性增长。
text
Round 1: prompt 500 tokens → model 调用 search → tool 返回空 → messages: +150 tokens
Round 2: messages 650 tokens → model 换 query → tool 返回空 → messages: +150 tokens
...
Round 5: messages 1250 tokens → model 放弃 → messages: +100 tokens (final answer)
总消耗: ~4000 tokens(正常应该是 ~800 tokens)修复:
- 在 tool 实现中添加 early terminate 逻辑:如果连续 2 轮空结果,tool 返回特定 signal(如
"NO_RESULTS_TERMINATE"),prompt 里明确告诉模型收到此 signal 后停止搜索 - 在每次 tool call 后检查累计 token 消耗,超过阈值就截断
- 加监控:
tool_call_repetition_counter指标,告警阈值 >3
案例 3:useChat 的闭包过期问题
现象: A/B 测试框架切换 temperature 参数后,部分请求仍使用旧参数。问题定位到 useChat 的 body option:
ts
const { messages, handleSubmit } = useChat({
body: {
temperature: abTestTemperature, // 闭包捕获的可能是旧值
},
});根因: useChat 内部对 body option 的处理是:在 hook 初始化时读取一次,后续请求复用。如果 body 依赖外部变量且未在 useRef 中追踪,handleSubmit 闭包里的 body 永远是初始值。
这个问题在 React 严格模式(StrictMode)下更容易暴露——hook 会 double-invoke 初始化逻辑。
解决方案:
ts
// 方案 1:用 experimental_prepareRequestBody(SDK 提供)
useChat({
experimental_prepareRequestBody: ({ messages, requestBody }) => ({
...requestBody,
temperature: abTestTemperature, // 每次请求实时计算
}),
});
// 方案 2:用 ref 追踪最新值
const temperatureRef = useRef(abTestTemperature);
temperatureRef.current = abTestTemperature;
useChat({
body: {
get temperature() { return temperatureRef.current; },
},
});案例 4:Edge Function 的 CPU Time 超限
现象: tool calling 请求在 4-5 轮后突然失败,前端报 「Network Error」,后端无任何错误日志。
根因: Vercel Edge Function 的 CPU time 限制为 30s(wall time 同样 30s,但 wall time 包含 I/O 等待)。一次 streamText 调用 LLM API 消耗 4-8s wall time,CPU time 仅有几毫秒(纯 I/O 等待不计 CPU)。但 tool execution 中的数据处理(如解析大型 JSON、数据聚合)消耗 CPU time 较高。analyze_chart_data tool 在第五轮调用时处理了前 4 轮累计的数据,CPU time 累计超 30s,Edge Function 被 SIGKILL 终止,未写入日志。
修复:
- 将 CPU-intensive tool execution 从 Edge 迁到 Serverless Function(Node.js runtime,60s wall time,更长 CPU budget)
- Vercel AI SDK 允许 tool 返回一个 async generator,把大数据处理拆成多个 chunk,减少单次 CPU spike
- 添加 CPU time 监控:在 tool 执行前后打点,如果某个 tool 的 CPU time >5s 就告警
生产 Checklist
部署 Vercel AI SDK 应用到生产前,检查这些项:
| 检查项 | 严重程度 | 说明 |
|---|---|---|
| onError handler + 用户友好的错误 UI | 🔴 必须 | 空 onError 用户看到的是"发送成功但没回复" |
| onFinish 中持久化消息到 DB | 🔴 必须 | 仅靠 React state,刷新即丢失 |
| abort 新请求时取消旧请求 | 🔴 必须 | SDK 默认开启,确保没有在 middleware 中禁用 |
| maxToolRoundtrips ≤ 3 | 🟡 常用约束 | 超过 3 轮的成本和延迟显著增加 |
| tool 内设置 timeout(3-5s) | 🟡 常用约束 | 避免单个慢 tool 拖死整个请求 |
| streaming 超长消息的定期 snapshot | 🟡 常用约束 | 30s+ 的长回复,中间状态值得保存 |
| 消息列表截断策略(>100 条时) | 🟡 常用约束 | 既为 token 成本,也为前端性能 |
| 添加 token 用量的 per-conversation 监控 | 🟢 生产补充项 | 避免单用户成本异常不被发现 |
| Provider fallback 配置 | 🟢 生产补充项 | OpenAI 挂了自动切 Anthropic,体验无感降级 |
| 对同一 conversation 的并发请求做防抖 | 🟢 生产补充项 | 用户快速双击发送 = 两条重复消息 |
扩展问题
以下内容整理了 Vercel AI SDK 在实现、运行时行为与生产约束上的延伸问题。
核心原理题
Q1:Vercel AI SDK 的 Data Stream Protocol 为什么不用 SSE?从协议设计角度分析两者的取舍。
期望答案要点:
SSE 的核心限制不在协议本身(SSE 支持 multi-event type,可以传任意字符串 payload),而在浏览器的 EventSource API:
EventSource只能发 GET 请求。AI 对话的 messages 数组可能数千 tokens,放 URL query string 或 header 都不现实。EventSource不支持自定义 header。无法传 Authorization token、租户 ID 等。EventSource的自动重连语义对 AI 场景不适用——重连后应该从断点续传还是重新生成?这是业务决策,不是协议能自动处理的。
Vercel 的 line-based protocol(0: / 2: / d: prefix)本质上是一个 multiplexed stream——单连接传输多种数据类型。SSE 也能做到(用 event: text / event: tool_call / event: data),但 SSE 的解析器在 EventSource API 层面,用 fetch + ReadableStream 的话需要自己写解析——既然都要自己写解析,不如设计一个更适合 AI 负载的协议。
跟进:如果让你重新设计这个 protocol,你会怎么改进?
Q2:useChat 在 streaming 期间如何处理竞态?如果用户快速连续发送 3 条消息,内部发生了什么?
期望答案要点:
每次 handleSubmit 会 abort 前一个请求的 AbortController(如果有),然后发起新请求。具体流程:
- 第 1 条消息 → 创建 AbortController#1 → fetch → reader#1 开始读取
- 第 2 条消息 →
abortController#1.abort()→ reader#1 的read()reject AbortError → 取消 request#1 → 创建 AbortController#2 → fetch → reader#2 - 第 3 条消息 → 同理取消 request#2
关键点:abort() 不保证 abort 之前已经 append 到 messages 的部分回滚。UI 上可能短暂出现第 1 条消息的 assistant 回复的前几个 chunk,然后被第 2 条消息的 UI 状态覆盖。这是因为 setMessages 的异步性和 React batching 之间的 timing。
Q3:LanguageModelV2 接口为什么要设计成 doGenerate + doStream 两个方法,而不是一个带 stream: boolean 参数的方法?
这是 API 设计中「接口隔离」vs「参数控制」的权衡。两个独立方法的好处:
- 返回类型不同(
doGenerate返回 final result,doStream返回 ReadableStream),类型系统可以精确表达 - 上游
streamText和generateText调用不同方法,编译时就能检查 - 某些 provider 的 stream 实现和 non-stream 实现差异很大(比如 Anthropic 的 Messages API streaming 走不同的 endpoint),统一参数会导致 provider adapter 内部大量分支逻辑
代价是每个 provider 需要实现两个方法,代码量翻倍。但实际上大部分 provider adapter 的实现是 doGenerate 内部用 doStream + 收集所有 chunks + 合并。
深入追问
Q4:Tool calling 执行流程中,tool 的返回值是如何流回模型并触发下一轮生成的?用代码层面的执行链描述。
期望答案要点:
text
streamText 内部的 tool calling loop:
1. 调用 model.doStream({ messages, tools })
2. 返回的 stream 中包含 type='tool-call' 的 chunk
3. DataStreamWriter 将 tool-call chunk 编码为 '2:' prefix 发给客户端
4. Stream 结束时 finishReason='tool-calls'
5. streamText 从 stream 结果中提取所有 ToolCall[]
6. 并发执行所有 tool:await Promise.all(toolCalls.map(tc => executeTool(tc)))
7. 构造新的 messages 数组:
[...oldMessages,
{ role: 'assistant', content: [{ type: 'tool-call', ... }] },
{ role: 'tool', content: [{ type: 'tool-result', toolCallId, result }] }
]
8. 如果 round < maxToolRoundtrips 且 finishReason 仍是 'tool-calls',回到步骤 1追问:如果 tool execution 阶段有一个 tool 抛异常,Promise.all 会立即 reject。这对已经在执行的 tool 和尚未执行的 tool 分别有什么影响?
Promise.all reject 后会触发整个 streamText 的 error 路径。已执行但未完成的 tool(HTTP 请求已发出但 response 还没到)取决于 tool 内部的 abort 处理——如果没有 abort,这些请求会继续完成但结果被丢弃。未执行的 tool 不会启动。更好的方案是用 Promise.allSettled,失败的 tool 返回 error string,让模型决定是否重试。
Q5:Vercel AI SDK 的 readDataStream 解析器如果遇到一个恶意构造的 chunk(比如包含未转义的 \n),会发生什么?怎么防御?
readDataStream 用 \n 做分隔符。行内容通过 JSON.parse() 解析。如果 LLM 输出的文本中包含 \n,生成的 chunk 格式类似:0:"hello\nworld"——这是安全的,JSON.parse 会把 \n 还原为真正的换行符。
但如果某个 chunk 的内容中包含字面的 \n 字符(不是 \n 转义),比如 0:"hello\nworld" 实际上是 0:"hello<LF>world",解析器的 buffer.split('\n') 会把这行拆成两行:0:"hello 和 world"。第一行 JSON.parse('"hello') 会失败(缺少闭合引号)。
防御方案:在解析前检查行内容是否为合法 JSON。更根本的方案是用 length-prefixed encoding 而不是 \n 分隔——每行开头 4 字节表示 payload 长度,就可以支持任意二进制 content。不过目前 LLM 输出的文本不太可能包含非转义的 \n,实际出现概率很低。
系统设计题
Q6:设计一个支持 100 万并发用户、实时 AI 对话的消息系统。Vercel AI SDK 在你架构中的位置是什么?它缺失的组件你打算怎么补?
期望答案要点(考察系统设计能力而非 SDK 知识):
text
架构概览:
┌─────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────┐
│ Browser │───│ API Gateway │───│ Chat Service │───│ LLM API │
│ (SDK) │ │ (rate limit, │ │ (streamText) │ │ │
└─────────┘ │ auth, route) │ └──────┬───────┘ └─────────┘
└──────────────┘ │
┌────┴───────┐
│ Message DB │
│ (PostgreSQL │
│ + Redis) │
└────────────┘SDK 的定位:仅用于 Chat Service 的内部——调用 LLM + streaming 返回。其他能力需自建:
- 消息持久化 — PostgreSQL(事务保证不丢消息)
- 实时推送 — WebSocket Gateway(替代 HTTP streaming 的 server→client 通道)
- 会话状态 — Redis(存 streaming 中间状态、rate limit counter、session metadata)
- 消息队列 — Redis Streams / Kafka(削峰填谷,LLM API 有限流时排队)
- Observability — OpenTelemetry tracing(从 useChat → API Gateway → Chat Service → LLM API 全链路)
故障场景题
Q7:生产环境中 assistant 返回的消息出现了「重复段落」——同一段内容在回复中出现了两次。分析可能的原因。
可能原因:
- 流式 chunk 被重放。 如果 middleware 或反向代理对 chunked response 做了 buffering + replay(某些 CDN 的重试逻辑),可能导致同一个 chunk 被客户端读了两次。
readDataStream的 buffer 拼接 bug。 如果解析器在处理 buffer 分片时,同一个 chunk 被两个循环处理(比如替换 buffer 时未清空),导致内容重复。- retry/重连逻辑不当。 如果业务层在
onError里调reload(),而连接实际没断(只是某个 chunk 解析失败),导致重新调 LLM 拿了完整回复,新回复和旧回复的残留内容拼接。 - LLM 本身的重复输出。 某些模型在 temperature=0 时可能逐字重复前面的输出。这是 LLM 的已知问题,跟 SDK 无关。
追问:你如何区分是 SDK 层面的 bug 还是 LLM 层面的问题?
对比分析题
Q8:如果不用 Vercel AI SDK,从头实现一个 streaming chatbot,你会怎么设计?对比你设计的方案和 SDK 的方案的差异。
这是一个开放题,考察候选人是否有能力独立设计类似系统。好的答案应该 cover:
- fetch + ReadableStream 的基础使用
- 自定义 stream parser 的设计
- 状态管理(messages 数组 + loading/error 状态)
- AbortController 管理
- Tool calling 的执行模型选择(串行 vs 并行)
- Provider 抽象层的粒度
评价标准不是「跟 SDK 一样」,而是「理解了 SDK 解决了什么问题、为什么那样设计、有什么替代方案和 tradeoff」。
收尾
回看整篇记录,真正需要抓住的核心还是两层:一层是 Data Stream Protocol 如何处理 streaming、tool calling 和 metadata 的统一传输;另一层是 SDK 如何通过 adapter 层把 provider 差异收敛到相对稳定的调用面。其余的大部分 API 设计、状态管理和故障模式,基本都可以从这两层继续往回推导。
